# Copyright 2007 Google Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""checks for OWNERS files recursively and only allow commits accordingly.

The idea is that every directory in DIR_ROOT can have a file named OWNERS.
This file determines who has write permissions to that directory, and
every directory beneath it.  Permissions are additive, so you have
permissions to write to file //repo/foo/bar/baz if you're listed in
//repo/OWNERS, //repo/foo/OWNERS, or //repo/foo/bar/OWNERS.

Note that syntax errors are logged, the relevant line ignored, and parsing
continues (i.e. it doesn't prevent access if another line allows it)

This blocks superusers from committing without approval when not in an
OWNERS file (but see bypass.py), but *does* allow a superuser's approval
to count even if he's not in OWNERS.

Superusers can override an OWNERS check like so:

> saruman:~/svn/hook-tests/epg-only$ svn commit file3
> Adding         file3
> Transmitting file data .svn: Commit failed (details follow):
>   svn: Commit blocked by pre-commit hook (exit code 2) with output:
>   merlin not authorized in any OWNERS file for trunk/epg-only/file3, sorry
>   svn: Your commit message was left in a temporary file:
>   svn:    '/home/merlin/svn/hook-tests/epg-only/svn-commit.2.tmp'

default commit works, try bypass-hooks:
> saruman:~/svn/hook-tests/epg-only$ svn commit --with-revprop gvn:bypass-hooks file3
> Adding         file3
> Transmitting file data .svn: Commit failed (details follow):
>   svn: Commit blocked by pre-commit hook (exit code 2) with output:
>   Non Superuser merlin tried to invoke gvn:bypass-hooks
>   svn: Your commit message was left in a temporary file:
>   svn:    '/home/merlin/svn/hook-tests/epg-only/svn-commit.7.tmp'

At this point, you need to set the gvn:superusers property on the root
of the repository:
> saruman:~/tmp$ svn co -N svn://svn/hook-tests/
>  U   hook-tests
> Checked out revision 15.
> saruman:~/tmp$ cd hook-tests/
> saruman:~/tmp/hook-tests$ svn propset  "gvn:superusers" "group:svn-team" .
> property 'gvn:superusers' set on '.'
> saruman:~/tmp/hook-tests$ svn commit

(and this will only work if you temporarily disable block_some_prop_mods.py
by moving it out long enough to install the gvn:superusers property).
After that, you can finally commit and override this hook:
> saruman:~/svn/hook-tests/epg-only$ gvn commit --with-revprop gvn:bypass-hooks file3
> Adding         file3
> Transmitting file data .
> Committed revision 17.
"""

import posixpath
import re

import svn.core
import svn.fs

import gvn.util


class OwnersCache(object):

  def __init__(self, hi, logger):
    """computes and keeps track of authorized owners and groups.

    Args:
      hi: HookInfo object
      logger: initialized logger object

    This object populates and keeps a dict of directory names to list of
    allowed users and groups
    """

    # Dictionary of directories to a list of 2 strings: users and groups
    self._dir_cache = {}
    self._hi = hi
    self._logger = logger

  def GetOwnerUsersGroups(self, path, recursive=True):
    """Computes and returns allowed users and groups.

    Args:
      path: checks the repository starting at path and working up
            must not have a trailing slash
      recursive: when implemented allows this function to check a given
                 level without working up (for OWNERS includes)
    Returns:
      two dictionaries: one with allowed users and one with allowed groups

    Method looks for OWNERS in path, and recurses all the way to the svn root
    """

    hi, logger = self._hi, self._logger
    logger.debug("path is " + path)

    if path in self._dir_cache:
      logger.debug(path + " cached, returning from _dir_cache")
      return self._dir_cache[path]

    if path == '':
      owner_file = 'OWNERS'
    else:
      owner_file = path + "/OWNERS"

    try:
      contents = svn.fs.file_contents(hi.head_root, owner_file, hi.pool)
    except svn.core.SubversionException, e:
      if e.apr_err not in [svn.core.SVN_ERR_FS_NOT_FOUND,
                           svn.core.SVN_ERR_FS_NOT_FILE]:
        raise
      # No OWNERS file, or not a file.
      contents = None

    if contents is None:
      users = set()
      groups = set()
    else:
      contents = svn.core.Stream(contents).read()
      (users, groups, noparent) = gvn.util.ParseOwners(contents)
      if noparent:
        recursive = False

    if recursive and path != "":
      parent = posixpath.dirname(path)
      parent_users, parent_groups = self.GetOwnerUsersGroups(parent)
      users.update(parent_users)
      groups.update(parent_groups)

    self._dir_cache[path] = (users, groups)
    logger.debug("after: %s %s" % self._dir_cache[path])
    return self._dir_cache[path]

def CheckUsers(check_users, users, groups, hi):
  """Return whether any of the users in check_users are allowed.

  If any of the users listed in check_users is listed in users or a
  member of a group listed in groups, return True, else False.

  Arguments:
  check_users   -- list of users to check
  users         -- set of allowed users
  groups        -- set of allowed groups
  hi            -- gvn.hooks.HookInfo
  """
  if set(check_users).intersection(users):
    # At least one of the check_users is an OWNER, so this
    # path-change is allowed.
    return True

  for group in groups:
    for user in check_users:
      if hi.userdb.UserInGroup(user, group):
        # This user is in an OWNER group, so this path-change is allowed.
        return True

  return False


def RunHook(hi, logger):
  """Implements gvn.hooks.runner's RunHook interface

  Returns:
    -1: pass and bypass other hooks
    0: pass
    1: fail
    "string": fail and print string to the user
  """
  # First, see if this is a changebranch snapshot, in which we don't
  # check OWNERS.
  cbp = hi.project_config['change-branch-base'] + '/' + hi.author
  if hi.prefix_changed == cbp or hi.prefix_changed.startswith(cbp + '/'):
    return 0

  # which directories we've already checked OWNERS access in
  checked_dir_list = {}
  owner = OwnersCache(hi, logger)

  approvers = []
  logger.debug("looking for approvers")
  gvn_prop = svn.fs.txn_prop(hi.txn, "gvn:change", hi.pool)
  if gvn_prop is not None:
    (user_name, change_name, gvn_rev) = gvn.util.ParseChangeName(gvn_prop)
    if None in [user_name, change_name, gvn_rev]:
      return "Invalid gvn:change value %s" % (gvn_prop,)
    props = svn.fs.revision_proplist(hi.fs, gvn_rev, hi.pool)
    logger.debug("got rev %s and props %s" % (gvn_rev, str(props)))
    for key in props:
      # this property is sanity checked by the hook that checks it in
      match = re.match(r"gvn:approve:(.+)", key)
      if match is not None:
        approver = match.group(1)
        logger.debug("Found approver %s in changeset %d" % (approver, gvn_rev))
        approvers.append(approver)
  else:
    logger.debug("No gvn:change in props (%s)" % str(gvn_prop))

  for (path, cl) in hi.paths_changed.iteritems():
    # Eat leading /
    path = path[1:]
    group_match = False

    # if a directory gets a modify request, it's a property change. Directory
    # changes are special since they are controlled by the OWNERS file in that
    # same directory, and not one level up
    if (svn.fs.check_path(hi.head_root, path, hi.pool) == svn.core.svn_node_dir
        and cl.change_kind == svn.fs.path_change_modify):
      directory = path
    else:
      # otherwise, we check OWNERS one level up
      directory = posixpath.dirname(path)

    logger.debug("%s in %s changed" % (path, directory))

    if directory in checked_dir_list:
      continue

    checked_dir_list[directory] = True

    users, groups = owner.GetOwnerUsersGroups(directory)
    if not users and not groups:
      logger.debug("%s authorized for %s (no OWNERS)" % (hi.author, path))
      continue

    if CheckUsers([hi.author], users, groups, hi):
      # Author is allowed for this path; continue to next path.
      logger.debug("%s directly allowed for %s" % (hi.author, path))
      continue
    else:
      # The author isn't allowed for this path, so check approvers.
      # Add in the superusers, so they can approve even if not in OWNERS.
      users.update(hi.super_users)
      groups.update(hi.super_groups)
      if CheckUsers(approvers, users, groups, hi):
        # Some approver is allowed for this path; continue to next path.
        logger.info("%s allowed by approval for %s" % (hi.author, path))
        continue

    # Neither the author nor any approvers are allowed for this path.
    return ("%s not authorized in any OWNERS file for %s, sorry"
            % (hi.author, path))
  # end for path

  logger.debug("All files authorized, success!")
  return 0
